import { debounce } from 'lodash-es'
import fs from 'fs'
import { createRequire } from 'module'
import { EventEmitter } from 'events'
import path from 'path'

const modules = new Map<string, ModuleInfo>()
const ProxyTypes = ['function', 'object']
const ModulePathSymbol = Symbol()
const ModuleRawSymbol = Symbol()
const globalRequire = createRequire(__filename)
const globalRequireCache = globalRequire.cache
const noop: (...args: any[]) => any = () => void 0

export const hotModuleExcludes: Array<(modulePath: string) => boolean> = [
  (modulePath) =>
    !path.isAbsolute(modulePath) || /[/\\]node_modules[/\\][a-z\d/@$-_]+/.test(modulePath),
]

const tryInvokeFunction = (func: Function, thisArg?: any, args?: any[]) => {
  try {
    return func.apply(thisArg, args)
  } catch (err) {
    console.error(err)
  }
}

interface ModuleInfo {
  module: HotModule
  require: NodeRequire
  reload: () => void
  exportsProxy: any
  deps: Set<string>
  importers: Set<string>
  watchers: Array<(newExports: any, oldExports: any) => void>
  obsolete: boolean
  shouldReload: boolean
}

export interface IHotModuleApi extends EventEmitter {
  data?: any
  preventReload(): void
  isPreventReload(): boolean
  isObsolete(): boolean
  isObsolete(modulePath: string): boolean
  isObsolete(exports: any): boolean
  removeModule(modulePath: string): void
  removeAllModules(): void
  on(event: 'unload', listener: (module: HotModule) => void): this
  on(event: 'beforeUnload', listener: (module: HotModule) => void): this
  off(event: 'unload', listener: (module: HotModule) => void): this
  off(event: 'beforeUnload', listener: (module: HotModule) => void): this
  addListener(event: 'unload', listener: (module: HotModule) => void): this
  addListener(event: 'beforeUnload', listener: (module: HotModule) => void): this
  prependListener(event: 'unload', listener: (module: HotModule) => void): this
  prependListener(event: 'beforeUnload', listener: (module: HotModule) => void): this
  removeListener(event: 'unload', listener: (module: HotModule) => void): this
  removeListener(event: 'beforeUnload', listener: (module: HotModule) => void): this
  watch<M>(
    watchedExports: M,
    listener: (newExports: M, oldExports: M) => void
  ): { stop: () => void }
  watch<M>(
    watchedModuleName: string,
    listener: (newExports: M, oldExports: M) => void
  ): { stop: () => void }
}

class HotModuleApi extends EventEmitter implements IHotModuleApi {
  constructor(private moduleinfo: ModuleInfo) {
    super({ captureRejections: true })
  }
  data?: any

  preventReload() {
    this.moduleinfo.shouldReload = false
  }

  isPreventReload() {
    return !this.moduleinfo.shouldReload
  }

  isObsolete(exports?: any) {
    const moduleInfo =
      exports != null
        ? modules.get(exports[ModulePathSymbol] ?? this.moduleinfo.require.resolve(exports))
        : this.moduleinfo
    return moduleInfo ? moduleInfo.obsolete : false
  }

  removeModule(modulePath: string): void {
    try {
      const modPath = this.moduleinfo.require.resolve(modulePath)
      removeModule(modPath)
    } catch {}
  }

  removeAllModules(): void {
    removeAllModules()
  }

  on(event: string | symbol, listener: (...args: any[]) => void) {
    super.on(event, listener)
    return this
  }

  off(event: string | symbol, listener: (...args: any[]) => void) {
    super.off(event, listener)
    return this
  }

  addListener(event: string | symbol, listener: (...args: any[]) => void) {
    super.addListener(event, listener)
    return this
  }

  prependListener(event: string | symbol, listener: (...args: any[]) => void) {
    super.prependListener(event, listener)
    return this
  }

  removeListener(event: string | symbol, listener: (...args: any[]) => void) {
    super.removeListener(event, listener)
    return this
  }

  watch(source: any, listener: any) {
    const modulePath =
      source?.[ModulePathSymbol] ??
      (typeof source === 'string' && this.moduleinfo.require.resolve(source))
    const watchedInfo = modules.get(modulePath)!
    if (!watchedInfo) {
      console.warn(`arguments[0] is not hot-module`)
      return { stop: noop }
    }
    watchedInfo.watchers.push(listener)
    const stop = () => {
      const watchers = watchedInfo.watchers
      const idx = watchers.indexOf(listener)
      if (idx >= 0) {
        watchers.splice(idx, 1)
      }
    }
    this.moduleinfo.module.hot.on('unload', stop)
    return { stop }
  }
}

export interface HotModule extends NodeModule {
  hot: IHotModuleApi
}

const createModuleInfo = (_module: NodeModule) => {
  let moduleInfo = modules.get(_module.filename)!
  if (moduleInfo) return moduleInfo
  const filename = _module.filename
  const _require = new Proxy(createRequire(filename), {
    apply: (_require, thisArg, [moduleName]) => {
      let exports: any
      const childPath = _require.resolve(moduleName)
      if (childPath === module.filename || !path.isAbsolute(childPath)) {
        return _require(moduleName)
      }
      let childInfo = modules.get(childPath)
      if (childInfo) {
        exports = childInfo.exportsProxy
      } else {
        exports = globalRequire(childPath)
        if (!hotModuleExcludes.some((exclude) => exclude(childPath))) {
          const childModule = globalRequireCache[childPath]
          if (childModule && path.isAbsolute(childModule.filename)) {
            childInfo = createModuleInfo(childModule)
            exports = childInfo.exportsProxy
          }
        } else {
          return toRawModuleExports(exports)
        }
      }
      if (childInfo) {
        childInfo.importers.add(moduleInfo.module.filename)
        moduleInfo.deps.add(childInfo.module.filename)
      }
      let rawExports = toRawModuleExports(exports)
      if (typeof rawExports !== 'object') {
        exports = rawExports
      }
      return exports
    },
  })

  modules.set(filename, (moduleInfo = Object.create(null)))

  const __module = (moduleInfo.module = _module as HotModule)
  __module.hot = new HotModuleApi(moduleInfo)
  moduleInfo.shouldReload = true
  moduleInfo.obsolete = false
  moduleInfo.require = _require
  moduleInfo.watchers = []
  moduleInfo.deps = new Set()
  moduleInfo.importers = new Set()
  const { proxy, setExports, getExports } = createExportsProxy(_module.exports, filename)
  moduleInfo.exportsProxy = proxy
  const _reload = () => {
    const filename = _module.filename
    moduleInfo.shouldReload = true
    moduleInfo.module.hot.emit('beforeUnload', moduleInfo.module)

    if (!moduleInfo.shouldReload) {
      moduleInfo.obsolete = false
      const { deps } = moduleInfo
      for (const dep of deps) {
        modules.get(dep)?.importers.add(filename)
      }

      return
    }

    const importers = [...moduleInfo.importers]
    moduleInfo.shouldReload = true
    moduleInfo.importers.clear()
    moduleInfo.deps.clear()
    const oldExports = getExports()

    moduleInfo.module.hot.emit('unload', moduleInfo.module)
    moduleInfo.module.hot.removeAllListeners()

    delete globalRequireCache[filename]
    const newExports = globalRequire(filename)
    setExports(newExports)

    moduleInfo.obsolete = false

    if (oldExports !== newExports) {
      const { watchers } = moduleInfo
      const watcherArgs = [newExports, oldExports]
      for (const watcher of watchers) {
        tryInvokeFunction(watcher, undefined, watcherArgs)
      }
    }

    setTimeout(() => {
      for (const importer of importers) {
        modules.get(importer)?.reload()
      }
    }, 24)
  }
  moduleInfo.reload = debounce(_reload, 50, { maxWait: 500 })

  let eventType: fs.WatchEventType
  const _watchFunc = () => {
    moduleInfo.obsolete = true
    switch (eventType) {
      case 'change':
        moduleInfo.reload()
        break
      case 'rename':
        if (fs.existsSync(filename)) {
          moduleInfo.reload()
        } else {
          fsWatcher.close()
          modules.delete(filename)
        }
        break
    }
  }

  const watchFunc = debounce(_watchFunc, 8, { maxWait: 100 })

  const fsWatcher = fs.watch(filename, (_eventType) => {
    eventType = _eventType
    watchFunc()
  })

  return moduleInfo
}

const createExportsProxy = (exports: any, modulePath: string) => {
  const proxy = new Proxy<any>(
    {
      get [Symbol.toStringTag]() {
        return `HotModule`
      },
    },
    {
      get: (_, p, receiver) => {
        if (p === ModulePathSymbol) return modulePath
        if (p === ModuleRawSymbol) return exports
        if (exports && ProxyTypes.includes(typeof exports)) {
          return Reflect.get(exports, p, receiver)
        }
      },
      set: (_, p, value, receiver) => {
        if (exports && ProxyTypes.includes(typeof exports)) {
          return Reflect.set(exports, p, value, receiver)
        }
        return false
      },
      apply: (_, thisArgument, argumentsList) => {
        if (typeof exports !== 'function')
          throw new TypeError(`Module ${modulePath} is a ${typeof exports} and not a function`)

        return Reflect.apply(exports, thisArgument, argumentsList)
      },
      construct: (_, argArray, newTarget) => {
        if (typeof exports !== 'function')
          throw new TypeError(`Module ${modulePath} is a ${typeof exports} and not a constructor`)

        return Reflect.construct(exports, argArray, newTarget)
      },
      defineProperty: (_, p, attributes) => {
        return Object.defineProperty(exports, p, attributes)
      },
      deleteProperty: (_, p) => {
        return delete exports[p]
      },
      getOwnPropertyDescriptor: (_, p) => {
        return Reflect.getOwnPropertyDescriptor(exports, p)
      },
      getPrototypeOf: (_) => {
        return Object.getPrototypeOf(exports)
      },
      has: (_, p) => {
        return p in exports
      },
      isExtensible: (_) => {
        return Object.isExtensible(exports)
      },
      ownKeys: (_) => {
        try {
          return Reflect.ownKeys(exports)
        } catch {
          return []
        }
      },
      preventExtensions: (_) => {
        return Object.preventExtensions(exports)
      },
      setPrototypeOf: (_, v) => {
        return Object.setPrototypeOf(exports, v)
      },
    }
  )

  const setExports = (newExports: any) => (exports = newExports)
  const getExports = () => exports

  return {
    proxy,
    setExports,
    getExports,
  }
}

export const hotModule = (_module: NodeModule) => {
  const { module: __module, require: _require } = createModuleInfo(_module)
  return { module: __module, require: _require }
}

export const toRawModuleExports = <T>(exports: T): T => {
  return (exports as any)?.[ModuleRawSymbol] ?? exports
}

export const getModuleExportsPath = (exports: any): string | undefined => {
  return (exports as any)?.[ModulePathSymbol]
}

export const removeModule = (modPath: string) => {
  const main = globalRequire.main?.filename
  if (main !== modPath) {
    if (modules.delete(modPath)) {
      delete globalRequireCache[modPath]
    }
  }
}

export const removeAllModules = () => {
  const main = globalRequire.main?.filename
  for (const key of modules.keys()) {
    if (main !== key) {
      delete globalRequireCache[key]
      modules.delete(key)
    }
  }
}

export const getLoadedModules = () => {
  return modules.values()
}

/**
 * @deprecated
 * @see addExcludeCondition
 */
export const registerHotModuleFilter = (filter: (modulePath: string) => boolean) => {
  if (typeof filter === 'function') {
    const exclude = (modulePath: string) => !filter(modulePath)
    exclude.__raw__ = filter
    hotModuleExcludes.push(exclude)
  }
}

/**
 * @deprecated
 * @see removeExcludeCondition
 */
export const unregisterHotModuleFilter = (filter: (modulePath: string) => boolean) => {
  const idx = hotModuleExcludes.findIndex((exclude: any) => exclude.__raw__ === filter)
  if (idx >= 0) {
    hotModuleExcludes.splice(idx, 1)
  }
}

export const addExcludeCondition = (condition: (modulePath: string) => boolean) => {
  typeof condition === 'function' && hotModuleExcludes.push(condition)
}

export const removeExcludeCondition = (condition: (modulePath: string) => boolean) => {
  const idx = hotModuleExcludes.indexOf(condition)
  if (idx >= 0) {
    hotModuleExcludes.splice(idx, 1)
  }
}
